Taeseong Blog

[채팅방 개선기 3] 새로고침해도 이어지는 AI 답변 만들기

2026-05-23

StreamingRedisSSEReactResumable Stream

먼저 결과부터 보자.

같은 채팅방을 서로 다른 탭이나 기기에서 열어둔 상태에서도 스트리밍 상태가 같이 움직이고, 생성 도중 새로고침하거나 탭을 닫아도 다시 이어서 볼 수 있게 됐다.

멀티 디바이스 스트리밍 동기화 및 재연결 동작
개선 전 — 생성 도중 새로고침하거나 탭을 닫으면 진행 중이던 답변이 사라졌고, 다른 기기에서는 생성 중이라는 사실도 알 수 없었다.
개선 후 — 여러 탭과 기기에서 스트리밍 상태를 공유하고, 새로고침 후에도 진행 중인 답변에 다시 붙을 수 있다.

AI 채팅에서 답변은 한 번에 완성돼서 도착하지 않는다. 모델이 토큰을 만드는 동안 브라우저는 스트림을 받아 계속 화면에 그린다.

그런데 답변이 생성되는 중간에 새로고침하면 어떻게 될까?

기존 베이비챗에서는 그 순간 답변이 사라졌다. 폰에서 질문을 보내고 PC에서 같은 방을 열어도 진행 중인 답변은 보이지 않았다. 처음에는 단순한 재연결 문제라고 생각했다.

"새로고침한 뒤 다시 스트림에 붙으면 되지 않을까?"

그런데 구조를 뜯어보니 더 근본적인 문제가 있었다.

재연결할 스트림 자체가 브라우저 연결과 함께 사라지고 있었다.

기존 구조는 모델의 응답 스트림을 HTTP response로 바로 흘려보내는 방식이었다.

브라우저
   │
   │ POST
   ▼
메시지 서버
   │
   │ LLM 생성
   ▼
HTTP Response Stream
   │
   ▼
브라우저

이 구조에서는 HTTP 연결이 끊기면 스트림도 사실상 끝난다. 새로고침은 단순히 화면만 다시 그리는 동작이 아니라, 생성 중인 답변의 생명줄을 끊는 일이었다. 게다가 이 문제는 UX에서 끝나지 않았다.

일부 사용자는 생성 중 새로고침이나 탭 종료를 반복하면서 답변을 다시 뽑고 있었다. 서버에서는 이미 LLM 비용이 발생했지만, 최종 저장과 재화 차감까지 가지 못했다. 결과적으로 일종의 무료 reroll 경로가 생긴 셈이다.

미리 말하면, 이번 작업 과정에서 이 어뷰징까지 함께 닫았다. 생성을 연결에서 떼어내자 불필요하게 새어 나가던 LLM 비용이 자연스럽게 줄일 수 있었다.

그래서 목표를 바꿨다.

브라우저가 사라져도 AI 생성은 계속 살아 있어야 한다.

새로고침은 클라이언트가 잠깐 없어지는 일일 뿐, 생성 자체가 끝나는 일이어서는 안 됐다.

생성 스트림을 브라우저 밖에도 남겼다

가장 먼저 한 일은 생성되는 스트림을 Redis에도 남기는 것이었다. resumable-stream과 ioredis 어댑터를 사용해 모델 스트림을 두 갈래로 나눴다.

                 ┌─▶ 기존 HTTP 응답
모델 스트림 ─────┤
                 └─▶ Redis 버퍼

처음 질문을 보낸 브라우저는 이전처럼 HTTP 스트림으로 바로 답변을 받는다. 동시에 같은 데이터가 Redis에도 쌓인다. 이렇게 하면 기존 스트리밍 경로를 크게 바꾸지 않으면서, 연결이 끊긴 뒤 다시 붙을 수 있는 기반이 생긴다.

재연결할 때는 Redis에 남아 있는 스트림을 다시 열면 된다.

const stream =
  await getStreamContext()
    .resumeExistingStream(streamId);

if (!stream) {
  return new Response(null, {
    status: 204,
  });
}

return new Response(stream, {
  headers:
    UI_MESSAGE_STREAM_HEADERS,
});

이제 브라우저가 사라져도 데이터는 서버 쪽에 남아 있다. 하지만 여기서 바로 다음 문제가 생겼다.

사용자가 방에 다시 들어왔을 때,

"지금 이 방에서 어떤 스트림이 생성 중이지?"

를 알아야 한다.

그래서 Redis에 방별 active stream 정보를 따로 저장했다. 키에는 roomId만 넣지 않았다. 서비스에서 roomId는 전역 유일값이 아니었기 때문이다.

{namespace}{domain}:room:
{userId}:{characterId}:{roomId}:
active-stream

사용자, 캐릭터, 방, 서비스 도메인까지 실제 유일성 범위를 키에 그대로 담았다.

스트림이 살아 있는지 판단하는 정보도 필요했다

active stream에는 TTL을 뒀다. 비정상 종료 때문에 정리되지 않은 키가 영원히 남는 걸 막기 위해서다. 다만 긴 답변은 생성 시간이 오래 걸릴 수 있어서, 생성 중에는 TTL을 주기적으로 연장했다.

정상 생성 중
→ TTL 연장

서버 이상 / 정리 실패
→ 시간이 지나면 자동 제거

또 처음에는 streamId만 저장했는데, 그것만으로는 화면 복원이 부족했다. 새로고침하면 최종 저장 전의 사용자 메시지도 사라질 수 있다. AI 답변만 다시 붙이면 "내가 무슨 질문을 했는지"가 보이지 않는 어색한 화면이 된다.

그래서 active stream에 userPrompt, redoTargetMessageId 같은 UI 문맥도 같이 저장했다. 재연결에서 필요한 건 스트림 데이터만이 아니었다.

그 스트림이 화면에서 어떤 상태를 의미하는지도 함께 복구해야 했다.

다른 탭과 기기까지 이어 붙이기

새로고침은 active stream을 조회해서 다시 붙으면 된다.

채팅방 진입
  ↓
active stream 조회
  ↓
streamId 발견
  ↓
resume 요청
  ↓
기존 스트림에 합류

그런데 다른 탭이나 다른 기기는 상황이 다르다. 예를 들어 PC에서 채팅방을 열어둔 상태로 폰에서 질문을 보내면, PC는 새로운 생성이 시작됐다는 사실 자체를 모른다. 다행히 Go API 서버에는 이미 방 단위 SSE relay가 있었다.

그래서 새로운 실시간 시스템을 만들지 않고 기존 이벤트에 두 가지만 추가했다.

generation_start
generation_end

메시지 서버가 생성을 시작하면 같은 방을 보고 있는 클라이언트들에게 generation_start를 보낸다. 다른 탭은 이 이벤트를 받는 순간 resume을 시도한다. 이렇게 새로고침, 다른 탭, 다른 기기가 모두 같은 재연결 경로를 쓰게 됐다.

여기서 중요한 레이스 컨디션도 하나 있었다. 처음에는 generation_start를 먼저 전파하고 그 뒤에 Redis stream을 등록했다.

generation_start
↓
다른 탭이 resume 요청
↓
Redis stream 등록

문제는 다른 탭이 너무 빨리 반응하면 아직 stream이 없다는 것이다. resume 요청은 204로 끝나고, 시작 이벤트는 이미 소비했다.

그래서 순서를 고정했다.

1. resumable stream 등록
2. active stream 저장
3. owner 저장
4. generation_start 전파

이벤트를 보내기 전에, 이벤트를 받은 쪽이 사용할 리소스부터 준비돼 있어야 했다.

streamId를 안다고 아무나 읽을 수 있어서는 안 됐다

resume API가 생기면서 권한 검증도 필요해졌다. streamId가 예측하기 어려운 값이어도, 그 자체를 권한으로 볼 수는 없다. 그래서 스트림을 시작할 때 owner 정보를 저장하고 resume 요청마다 확인했다.

const owner =
  await getStreamOwner(streamId);

if (
  !owner ||
  owner.userId !== userId ||
  owner.characterId !== characterId
) {
  return new Response(null, {
    status: 204,
  });
}

처음에는 userId만 확인했는데, 같은 사용자의 다른 캐릭터 스트림까지 접근할 수 있다는 걸 발견해서 characterId도 같이 검증했다. 결국 중요한 건 "이 ID를 알고 있는가"가 아니라,

"이 스트림을 읽을 권한이 있는가"였다.

메시지 서버가 Go 서버로 생성 상태를 알리는 내부 API도 일반 사용자 인증과 분리했다. 재연결 기능을 만들다 보니 스트리밍 로직뿐 아니라 서비스 간 신뢰 경계까지 같이 다시 보게 됐다.

정상 종료보다 더 신경 쓴 건 비정상 종료였다

AI 생성은 항상 깔끔하게 끝나지 않는다. 모델 API 오류, 사용자 취소, 검열, stream abort, 네트워크 예외 등 여러 경로가 있다. 어느 경우든 마지막에는 반드시 해야 할 일이 있었다.

  • active stream 제거
  • generation_end 전파

하나라도 빠지면 이미 끝난 답변이 계속 "생성 중"으로 남는다. 그래서 done, error, cancel을 모두 하나의 settle 경로로 모았다. 그리고 finalize 로직도 streamId 기준으로 멱등 처리했다.

if (
  finalizedStreams.has(
    ctx.streamId
  )
) {
  return;
}

finalizedStreams.add(
  ctx.streamId
);

정상 finish와 별도 정리 로직이 둘 다 실행되더라도 종료 이벤트는 한 번만 나간다.

서버 흐름을 줄이면 결국 이 정도다.

[생성 시작]

message-server
   │
   ├─ resumable stream 등록
   ├─ active / owner 저장
   └─ generation_start
          │
          ▼
        Go API
          │
          ▼
      Redis Pub/Sub
          │
          ▼
    같은 방의 클라이언트

재연결은 이렇게 된다.

채팅방 진입
  ↓
active stream 조회
  ↓
owner 검증
  ↓
Redis stream 재생

그리고 생성이 끝나면,

done / error / cancel
          ↓
        settle
          ↓
 active 삭제 + generation_end

여기까지 오면서 생성은 더 이상 특정 브라우저 연결에 묶여 있지 않게 됐다.

프론트에서는 SDK가 해주던 일을 직접 책임져야 했다

기존에는 AI SDK의 useCompletion으로 POST 스트림을 처리하고 있었다. 하지만 resume endpoint는 이미 진행 중인 스트림을 GET으로 다시 여는 구조였다. 그래서 resume 전용 reader를 만들었다.

const reader =
  response.body.getReader();

const decoder =
  new TextDecoder();

let buffer = '';
let accumulated = '';

여기서부터는 SDK가 대신 처리해주던 경계 조건을 직접 챙겨야 했다. 가장 먼저 알게 된 건 reader.read() 한 번과 SSE 이벤트 하나가 같은 단위가 아니라는 점이었다.

chunk 1:
data: {...}\n\ndata: {"

chunk 2:
type":"text-delta"...}\n\n

이벤트 중간에서 chunk가 끊길 수도 있고, 한 chunk 안에 이벤트가 여러 개 들어올 수도 있다. 그래서 데이터를 buffer에 계속 붙이고 \n\n이 완성된 이벤트만 꺼내 처리했다.

buffer += decoder.decode(
  value,
  { stream: true }
);

let sepIndex =
  buffer.indexOf('\n\n');

while (sepIndex !== -1) {
  const rawEvent =
    buffer.slice(0, sepIndex);

  buffer =
    buffer.slice(sepIndex + 2);

  handleRawEvent(rawEvent);

  sepIndex =
    buffer.indexOf('\n\n');
}

또 하나 위험했던 건 "오류가 난 상태"보다 "아무 일도 안 일어나는 상태"였다. 연결은 열려 있는데 데이터가 안 오고, 서버도 close하지 않으면 reader.read()가 계속 기다린다. 그 상태에서는 입력창도 계속 잠길 수 있다.

그래서 일정 시간 동안 데이터가 없으면 reader를 취소하는 watchdog을 넣었다. 스트림 종료 뒤에는 decoder를 flush해서 마지막 buffer도 처리했다.

buffer += decoder.decode();

if (buffer.length > 0) {
  handleRawEvent(buffer);
}

스트리밍에서는 "거의 항상 잘 된다"가 생각보다 위험했다.

2편의 RAF 페이서와도 충돌했다

2편에서 AI 답변을 부드럽게 보여주기 위해 RAF 기반 페이서를 만들었다. resume이 들어오면서 두 시스템이 같은 메시지를 동시에 수정할 수 있게 됐다.

resume reader
→ Redis에서 복구한 최신 텍스트를 store에 기록

RAF pacer
→ 기존 useCompletion 값을 store에 기록

resume이 더 최신 데이터를 썼는데 다음 RAF tick에서 오래된 completion이 덮어쓰면 글자가 뒤로 돌아간다. 그래서 resume 중에는 기존 페이서를 잠깐 막았다. 처음에는 resumeActive boolean 하나를 썼다.

그런데 방 A의 resume이 끝나기 전에 방 B로 빠르게 이동하면 레이스가 생겼다.

A resume 시작
→ resumeActive = true

B resume 시작
→ resumeActive = true

A resume 종료
→ resumeActive = false

B는 아직 resume 중

A가 늦게 끝나면서 B의 gate까지 풀어버릴 수 있다. 그래서 resumeOwner를 추가해 현재 gate를 누가 잡고 있는지까지 관리했다.

비동기 작업이 겹치기 시작하면 boolean보다 소유권이 더 중요할 때가 많았다.

배포 다음 날, 사용자 반응으로 효과를 확인했다

이 작업은 효과를 숫자로 보여주기가 꽤 어려웠다. 어뷰징이 막혔다는 건 결국 원래 일어나던 일이 더 이상 일어나지 않는 것이다. 로그에는 새로 생긴 이벤트는 잘 남지만, 사라진 행동은 눈에 잘 띄지 않는다.

특히 생성 도중 새로고침해서 답변을 다시 뽑던 경우가 얼마나 줄었는지는 서버 로그만으로 확인하기가 어려웠다. 그런데 배포 다음 날, 커뮤니티에 글이 하나 올라왔다.

커뮤니티 게시글: 이제 리롤 새로고침 막혔네
배포 다음 날인 2026-05-29에 올라온 글. 우리가 "새로고침해도 답변이 이어진다"로 만든 변화가, 사용자 쪽에서는 "리롤 새로고침이 막혔다"로 먼저 보였다.

우리는 새로고침 후에도 답변이 이어지게 만드는 UX 개선으로 시작했다. 그런데 사용자들이 가장 먼저 알아챈 건, 그동안 쓰던 우회 방법이 더 이상 통하지 않는다는 점이었다. 댓글을 보니 이 사용법이 생각보다 꽤 널리 알려져 있었다.

커뮤니티 댓글 반응
다른 서비스에서도 비슷한 이유로 막았다는 이야기와, 이 행동을 어뷰징으로 보는 반응이 함께 달렸다.
답변 도중 새로고침 관련 커뮤니티 댓글
"답변 방향이 마음에 안 들면 새로고침해서 다시 받는다"는 방식이 실제로 사용자들 사이에서 공유되고 있었다.

이 반응을 보면서 몇 가지를 다시 확인할 수 있었다. 우선 이건 단순한 엣지 케이스가 아니었다. 답변이 마음에 들지 않으면 생성 중간에 새로고침해서 다시 받는 방식이 실제 사용법처럼 퍼져 있었다.

비용도 이미 발생하고 있었다. 사용자가 연결을 끊는 시점에는 LLM 토큰이 상당 부분 소비된 뒤였지만, 최종 저장과 재화 차감까지 이어지지 않았다. 사용자 입장에서는 무료로 한 번 더 생성할 수 있었던 것이다. 이번 개선으로 생성의 생명주기를 HTTP 연결에서 분리했다.

그러자 브라우저 연결이 끊겨도 생성은 계속 진행됐고, 정상적으로 저장과 정산까지 이어졌다. 결과적으로 reroll이 성립하던 조건 자체가 사라졌다. 아쉬운 점은 비용 절감 효과를 숫자로 남기지 못했다는 것이다.

"앞으로 발생하지 않을 요청"을 사후에 세는 건 어렵고, 당시에는 지표 설계보다 기능을 안정적으로 배포하는 게 더 급했다. 지금 다시 한다면 배포 전부터 생성 시작 대비 정상 종료 비율이나 생성 시작 후 저장까지 완료된 비율 같은 지표를 남겨뒀을 것 같다.

결국 새로고침 문제는 '생성의 주인이 누구인가'의 문제였다

처음에는 단순히 "새로고침 후 다시 붙는 기능"을 만들고 싶었다. 그런데 구현을 끝내고 보니 더 큰 변화가 생겼다.

기존에는 이런 경로가 가능했다.

LLM 비용 발생
↓
클라이언트 연결 끊김
↓
최종 저장 실패
↓
재화 차감도 완료되지 않음

생성을 HTTP 연결에서 분리한 뒤에는 클라이언트가 사라져도 서버 생성은 자기 생명주기를 계속 가진다. 정상 종료든 예외 종료든 상태 관리와 정리 역시 서버를 기준으로 이어진다. 결과적으로 새로고침 UX를 개선한 구조가 비용 누수와 reroll 경로까지 같이 줄였다.

전체 구조는 꽤 많은 레이어를 거친다.

LLM 생성
↓
메시지 서버
↓
Redis resumable stream
↓
active stream / owner
↓
Go API
↓
Redis Pub/Sub
↓
방 단위 SSE
↓
React 클라이언트

하지만 실제로 어려웠던 건 각 레이어 자체가 아니었다. 서버가 스트림을 남겨도 프론트가 SSE 경계를 잘못 읽으면 데이터가 사라진다. resume이 잘 돼도 기존 RAF 페이서가 오래된 값을 다시 덮으면 화면이 깨진다.

종료 처리가 빠지면 끝난 스트림이 계속 살아 있는 것처럼 보인다.

결국 어려웠던 건 하나였다.

레이어와 레이어 사이의 경계 조건을 빠뜨리지 않고 연결하는 것.

이 구조를 만든 뒤 새로고침은 더 이상 "생성 종료"를 의미하지 않게 됐다. 브라우저는 스트림을 보는 여러 클라이언트 중 하나가 됐고,

생성의 실제 생명주기는 서버가 가지게 됐다.

돌이켜보면 세 편의 채팅방 개선 작업은 같은 방향으로 이어졌다. 1편에서는 메시지 수와 렌더링 비용을 분리했다. 2편에서는 토큰이 도착하는 속도와 화면에 보여주는 속도를 분리했다.

3편에서는 모델 생성과 HTTP 연결의 생명주기를 분리했다. 성능이나 안정성이 무너지는 지점에는 서로 다른 책임이 너무 가까이 붙어 있는 경우가 많았다. 이번 채팅방 개선 작업은 결국 그 결합을 하나씩 끊어내는 과정이었다.